#!/bin/sh
# vim: set ts=4:

# Exit on error and treat unset variables as an error.
set -eu

#
# LXC template for Alpine Linux 3+
#

# Note: Do not replace tabs with spaces, it would break heredocs!

# Authors:
# Jakub Jirutka <jakub@jirutka.cz>

# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA


#===========================  Constants  ============================#

# Make sure the usual locations are in PATH
export PATH=$PATH:/usr/sbin:/usr/bin:/sbin:/bin

readonly LOCAL_STATE_DIR='@LOCALSTATEDIR@'
readonly LXC_TEMPLATE_CONFIG='@LXCTEMPLATECONFIG@'
readonly LXC_CACHE_DIR="${LXC_CACHE_PATH:-"$LOCAL_STATE_DIR/cache/lxc"}/alpine"

# SHA256 checksums of GPG keys for APK.
readonly APK_KEYS_SHA256="\
9c102bcc376af1498d549b77bdbfa815ae86faa1d2d82f040e616b18ef2df2d4  alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub
2adcf7ce224f476330b5360ca5edb92fd0bf91c92d83292ed028d7c4e26333ab  alpine-devel@lists.alpinelinux.org-4d07755e.rsa.pub
ebf31683b56410ecc4c00acd9f6e2839e237a3b62b5ae7ef686705c7ba0396a9  alpine-devel@lists.alpinelinux.org-5243ef4b.rsa.pub
1bb2a846c0ea4ca9d0e7862f970863857fc33c32f5506098c636a62a726a847b  alpine-devel@lists.alpinelinux.org-524d27bb.rsa.pub
12f899e55a7691225603d6fb3324940fc51cd7f133e7ead788663c2b7eecb00c  alpine-devel@lists.alpinelinux.org-5261cecb.rsa.pub"

readonly APK_KEYS_URI='http://alpinelinux.org/keys'
readonly MIRRORS_LIST_URL='http://rsync.alpinelinux.org/alpine/MIRRORS.txt'

: ${APK_KEYS_DIR:=/etc/apk/keys}
if ! ls "$APK_KEYS_DIR"/alpine* >/dev/null 2>&1; then
	APK_KEYS_DIR="$LXC_CACHE_DIR/bootstrap/keys"
fi
readonly APK_KEYS_DIR

: ${APK:=$(command -v apk || true)}
if [ ! -x "$APK" ]; then
	APK="$LXC_CACHE_DIR/bootstrap/sbin/apk.static"
fi
readonly APK


#========================  Helper Functions  ========================#

usage() {
	cat <<-EOF
		Template specific options can be passed to lxc-create after a '--' like this:

		   lxc-create --name=NAME [lxc-create-options] -- [template-options] [PKG...]

		PKG  Additional APK package(s) to install into the container.

		Template options:
		   -a ARCH, --arch=ARCH   The container architecture (e.g. x86, x86_64); defaults
		                          to the host arch.
		   -d, --debug            Run this script in a debug mode (set -x and wget w/o -q).
		   -F, --flush-cache      Remove cached files before build.
		   -m URL --mirror=URL    The Alpine mirror to use; defaults to random mirror.
		   -r VER, --release=VER  The Alpine release branch to install; default is the
		                          latest stable.

		Environment variables:
		   APK             The apk-tools binary to use when building rootfs. If not set
		                   or not executable and apk is not on PATH, then the script
		                   will download the latest apk-tools-static.
		   APK_KEYS_DIR    Path to directory with GPG keys for APK. If not set and
		                   /etc/apk/keys does not contain alpine keys, then the script
		                   will download the keys from ${APK_KEYS_URI}.
		   LXC_CACHE_PATH  Path to the cache directory where to store bootstrap files
		                   and APK packages.
	EOF
}

die() {
	local retval=$1; shift

	printf 'ERROR: %s\n' "$@" 1>&2
	exit $retval
}

einfo() {
	printf "\n==> $1\n"
}

fetch() {
	if [ "$DEBUG" = 'yes' ]; then
		wget -T 10 -O - $@
	else
		wget -T 10 -O - -q $@
	fi
}

latest_release_branch() {
	local arch="$1"
	local branch=$(fetch "$MIRROR_URL/latest-stable/releases/$arch/latest-releases.yaml" \
		| sed -En 's/^[ \t]*branch: (.*)$/\1/p' \
		| head -n 1)
	[ -n "$branch" ] && echo "$branch"
}

parse_arch() {
	case "$1" in
		x86 | i[3-6]86) echo 'x86';;
		x86_64 | amd64) echo 'x86_64';;
		aarch64 | arm64) echo 'aarch64';;
		armv7) echo 'armv7';;
		arm*) echo 'armhf';;
		*) return 1;;
	esac
}

random_mirror_url() {
	local url=$(fetch "$MIRRORS_LIST_URL" | shuf -n 1)
	[ -n "$url" ] && echo "$url"
}

run_exclusively() {
	local lock_name="$1"
	local timeout=$2
	shift 2

	mkdir -p "$LOCAL_STATE_DIR/lock/subsys"

	local retval
	{
		echo -n "Obtaining an exclusive lock..."
		if ! flock -x 9; then
			echo ' failed.'
			return 1
		fi
		echo ' done'

		"$@"; retval=$?
	} 9> "$LOCAL_STATE_DIR/lock/subsys/lxc-alpine-$lock_name"

	return $retval
}


#============================  Bootstrap  ===========================#

bootstrap() {
	if [ "$FLUSH_CACHE" = 'yes' ] && [ -d "$LXC_CACHE_DIR/bootstrap" ]; then
		einfo 'Cleaning cached bootstrap files'
		rm -Rf "$LXC_CACHE_DIR/bootstrap"
	fi

	einfo 'Fetching and/or verifying APK keys'
	fetch_apk_keys "$APK_KEYS_DIR"

	if [ ! -x "$APK" ]; then
		einfo 'Fetching apk-tools static binary'

		local host_arch=$(parse_arch $(uname -m))
		fetch_apk_static "$LXC_CACHE_DIR/bootstrap" "$host_arch"
	fi
}

fetch_apk_keys() {
	local dest="$1"
	local line keyname

	mkdir -p "$dest"
	cd "$dest"

	echo "$APK_KEYS_SHA256" | while read -r line; do
		keyname="${line##* }"
		if [ ! -f "$keyname" ]; then
			fetch "$APK_KEYS_URI/$keyname" > "$keyname"
		fi
		echo "$line" | sha256sum -c -
	done || exit 2

	cd - >/dev/null
}

fetch_apk_static() {
	local dest="$1"
	local arch="$2"
	local pkg_name='apk-tools-static'

	mkdir -p "$dest"

	local pkg_ver=$(fetch "$MIRROR_URL/latest-stable/main/$arch/APKINDEX.tar.gz" \
		| tar -xzO APKINDEX \
		| sed -n "/P:${pkg_name}/,/^$/ s/V:\(.*\)$/\1/p")

	[ -n "$pkg_ver" ] || die 2 "Cannot find a version of $pkg_name in APKINDEX"

	fetch "$MIRROR_URL/latest-stable/main/$arch/${pkg_name}-${pkg_ver}.apk" \
		| tar -xz -C "$dest" sbin/  # --extract --gzip --directory

	[ -f "$dest/sbin/apk.static" ] || die 2 'apk.static not found'

	local keyname=$(echo "$dest"/sbin/apk.static.*.pub | sed 's/.*\.SIGN\.RSA\.//')
	openssl dgst -sha1 \
		-verify "$APK_KEYS_DIR/$keyname" \
		-signature "$dest/sbin/apk.static.SIGN.RSA.$keyname" \
		"$dest/sbin/apk.static" \
		|| die 2 'Signature verification for apk.static failed'

	# Note: apk doesn't return 0 for --version
	local out="$("$dest"/sbin/apk.static --version)"
	echo "$out"

	[ "${out%% *}" = 'apk-tools' ] || die 3 'apk.static --version failed'
}


#============================  Install  ============================#

install() {
	local dest="$1"
	local arch="$2"
	local branch="$3"
	local extra_packages="$4"
	local apk_cache="$LXC_CACHE_DIR/apk/$arch"
	local repo_url="$MIRROR_URL/$branch/main"

	if [ "$FLUSH_CACHE" = 'yes' ] && [ -d "$apk_cache" ]; then
		einfo "Cleaning cached APK packages for $arch"
		rm -Rf "$apk_cache"
	fi
	mkdir -p "$apk_cache"

	einfo "Installing Alpine Linux in $dest"
	cd "$dest"

	mkdir -p etc/apk
	ln -s "$apk_cache" etc/apk/cache
	echo "$repo_url" > etc/apk/repositories

	install_packages "$arch" "alpine-base $extra_packages"
	make_dev_nodes
	setup_inittab
	setup_hosts
	setup_network
	setup_services

	chroot . /bin/true \
		|| die 3 'Failed to execute /bin/true in chroot, the builded rootfs is broken!'

	rm etc/apk/cache
	cd - >/dev/null
}

install_packages() {
	local arch="$1"; shift
	local packages="$@"

	$APK --arch="$arch" --root=. --keys-dir="$APK_KEYS_DIR" \
		--update-cache --initdb add $packages
}

make_dev_nodes() {
	mkdir -p -m 755 dev/pts
	mkdir -p -m 1777 dev/shm

	mknod -m 666 dev/zero c 1 5
	mknod -m 666 dev/full c 1 7
	mknod -m 666 dev/random c 1 8
	mknod -m 666 dev/urandom c 1 9

	local i; for i in $(seq 0 4); do
		mknod -m 620 dev/tty$i c 4 $i
		chown 0:5 dev/tty$i  # root:tty
	done

	mknod -m 666 dev/tty c 5 0
	chown 0:5 dev/tty  # root:tty
	mknod -m 620 dev/console c 5 1
	mknod -m 666 dev/ptmx c 5 2
	chown 0:5 dev/ptmx  # root:tty
}

setup_inittab() {
	# Remove unwanted ttys.
	sed -i '/^tty[5-9]\:\:.*$/d' etc/inittab

	cat <<-EOF >> etc/inittab
		# Main LXC console console
		::respawn:/sbin/getty 38400 console
	EOF
}

setup_hosts() {
	# This runscript injects localhost entries with the current hostname
	# into /etc/hosts.
	cat <<'EOF' > etc/init.d/hosts
#!/sbin/openrc-run

start() {
	local start_tag='# begin generated'
	local end_tag='# end generated'

	local content=$(
		cat <<-EOF
			$start_tag by /etc/init.d/hosts
			127.0.0.1  $(hostname).local $(hostname) localhost
			::1        $(hostname).local $(hostname) localhost
			$end_tag
		EOF
	)

	if grep -q "^${start_tag}" /etc/hosts; then
		# escape \n, busybox sed doesn't like them
		content=${content//$'\n'/\\$'\n'}

		sed -ni "/^${start_tag}/ {
				a\\${content}
				# read and discard next line and repeat until $end_tag or EOF
				:a; n; /^${end_tag}/!ba; n
			}; p" /etc/hosts
	else
		printf "$content" >> /etc/hosts
	fi
}
EOF
	chmod +x etc/init.d/hosts

	# Wipe it, will be generated by the above runscript.
	echo -n > etc/hosts
}

setup_network() {
	# Note: loopback is automatically started by LXC.
	cat <<-EOF > etc/network/interfaces
		auto eth0
		iface eth0 inet dhcp
		hostname \$(hostname)
	EOF
}

setup_services() {
	local svc_name

	# Specify the LXC subsystem.
	sed -i 's/^#*rc_sys=.*/rc_sys="lxc"/' etc/rc.conf

	# boot runlevel
	for svc_name in bootmisc hosts syslog; do
		ln -s /etc/init.d/$svc_name etc/runlevels/boot/$svc_name
	done

	# default runlevel
	for svc_name in networking cron crond; do
		# issue 1164: alpine renamed cron to crond
		# Use the one that exists.
		if [ -e etc/init.d/$svc_name ]; then
			ln -s /etc/init.d/$svc_name etc/runlevels/default/$svc_name
		fi
	done
}


#===========================  Configure  ===========================#

configure_container() {
	local config="$1"
	local hostname="$2"
	local arch="$3"

	cat <<-EOF >> "$config"

		# Specify container architecture.
		lxc.arch = $arch

		# Set hostname.
		lxc.utsname = $hostname

		# If something doesn't work, try to comment this out.
		# Dropping sys_admin disables container root from doing a lot of things
		# that could be bad like re-mounting lxc fstab entries rw for example,
		# but also disables some useful things like being able to nfs mount, and
		# things that are already namespaced with ns_capable() kernel checks, like
		# hostname(1).
		lxc.cap.drop = sys_admin

		# Include common configuration.
		lxc.include = $LXC_TEMPLATE_CONFIG/alpine.common.conf
	EOF
}


#=============================  Main  ==============================#

if [ "$(id -u)" != "0" ]; then
	die 1 "This script must be run as 'root'"
fi

# Parse command options.
options=$(getopt -o a:dFm:n:p:r:h -l arch:,debug,flush-cache,mirror:,name:,\
path:,release:,rootfs:,help,mapped-uid:,mapped-gid: -- "$@")
eval set -- "$options"

# Clean variables and set defaults.
arch="$(uname -m)"
debug='no'
flush_cache='no'
mirror_url=
name=
path=
release=
rootfs=

# Process command options.
while [ $# -gt 0 ]; do
	case $1 in
		-a | --arch)
			arch=$2; shift 2
		;;
		-d | --debug)
			debug='yes'; shift 1
		;;
		-F | --flush-cache)
			flush_cache='yes'; shift 1
		;;
		-m | --mirror)
			mirror_url=$2; shift 2
		;;
		-n | --name)
			name=$2; shift 2
		;;
		-p | --path)
			path=$2; shift 2
		;;
		-r | --release)
			release=$2; shift 2
		;;
		--rootfs)
			rootfs=$2; shift 2
		;;
		-h | --help)
			usage; exit 0
		;;
		--)
			shift; break
		;;
		--mapped-[ug]id)
			die 1 "This template can't be used for unprivileged containers." \
				'You may want to try the "download" template instead.'
		;;
		*)
			echo "Unknown option: $1" 1>&2
			usage; exit 1
		;;
	esac
done

extra_packages="$@"

[ "$debug" = 'yes' ] && set -x

# Set global variables.
readonly DEBUG="$debug"
readonly FLUSH_CACHE="$flush_cache"
readonly MIRROR_URL="${mirror_url:-$(random_mirror_url)}"

# Validate options.
[ -n "$name" ] || die 1 'Missing required option --name'
[ -n "$path" ] || die 1 'Missing required option --path'

if [ -z "$rootfs" ] && [ -f "$path/config" ]; then
	rootfs="$(sed -nE 's/^lxc.rootfs\s*=\s*(.*)$/\1/p' "$path/config")"
fi
if [ -z "$rootfs" ]; then
	rootfs="$path/rootfs"
fi

arch=$(parse_arch "$arch") \
	|| die 1 "Unsupported architecture: $arch"

if [ -z "$release" ]; then
	release=$(latest_release_branch "$arch") \
		|| die 2 'Failed to resolve Alpine last release branch'
fi

# Here we go!
run_exclusively 'bootstrap' 10 bootstrap
run_exclusively "$arch" 30 install "$rootfs" "$arch" "$release" "$extra_packages"
configure_container "$path/config" "$name" "$arch"

einfo "Container's rootfs and config have been created"
cat <<-EOF
	Edit the config file $path/config to check/enable networking setup.
	The installed system is preconfigured for a loopback and single network
	interface configured via DHCP.

	To start the container, run "lxc-start -n $name".
	The root password is not set; to enter the container run "lxc-attach -n $name".
EOF
